2026_mr_oizo_flat_beat.py

#

SPDX-FileCopyrightText: 2026 Eole Fontaine Wolfs SPDX-FileCopyrightText: 2026 AlICe laboratory https://alicelab.be

SPDX-License-Identifier: GPL-3.0-or-later

import math
import random

import bmesh
import bpy
from mathutils import Euler, Matrix, Vector
#

CONFIGURATION GLOBALE Toutes les constantes sont centralisées ici pour faciliter les ajustements

CUBE_SIZE = 10.0  # Taille du cube de délimitation
OFFSET = CUBE_SIZE / 2.0  # Décalage vertical pour centrer la boîte
THICK_K = 0.75  # Épaisseur des barres sur les temps forts (Kick)
THICK_S = 0.25  # Épaisseur des barres sur les temps faibles (Snare)
OVERSHOOT = 2.0  # Dépassement des barres hors de la boîte avant découpe
POS = [
    -3.75,
    -1.25,
    1.25,
    3.75,
]  # Positions des 4 rangées/colonnes de la grille rythmique
#

Base de données des moments musicaux Chaque entrée définit un “état” du générateur avec ses paramètres propres : rhythm : séquence Kick/Snare qui détermine l’épaisseur des barres wobble_amp : amplitude de la déformation de la surface fluide time_ratio : position temporelle dans le morceau (influence la hauteur du wobble) presence : intensité de la fréquence secondaire du wobble parasite_count : nombre de base de parasites générés parasite_types : types de parasites disponibles (‘Alpha’ = aiguille, ‘Beta’ = cône large)

MOMENTS = {
    "1_The_Intro": {
        "rhythm": ["K", "S", "K", "S"],
        "wobble_amp": 1.0,
        "time_ratio": 0.2,
        "presence": 1.6,
        "parasite_count": 0,
        "parasite_types": [],
    },
    "2_The_Build_Up": {
        "rhythm": ["K", "S", "K", "S"],
        "wobble_amp": 1.3,
        "time_ratio": 0.5,
        "presence": 1.1,
        "parasite_count": 8,
        "parasite_types": ["Alpha"],
    },
    "3_The_Drop": {
        "rhythm": ["K", "S", "K", "S"],
        "wobble_amp": 2.0,
        "time_ratio": 0.7,
        "presence": 1.9,
        "parasite_count": 15,
        "parasite_types": ["Alpha", "Beta"],
    },
    "4_The_Bridge": {
        "rhythm": ["K", "S", "K", "S"],
        "wobble_amp": 1.5,
        "time_ratio": 0.3,
        "presence": 0.7,
        "parasite_count": 12,
        "parasite_types": ["Alpha"],
    },
    "5_The_Chaos": {
        "rhythm": ["K", "S", "K", "S"],
        "wobble_amp": 2.3,
        "time_ratio": 0.75,
        "presence": 0.3,
        "parasite_count": 18,
        "parasite_types": ["Alpha", "Beta"],
    },
    "6_The_Outro": {
        "rhythm": ["K", "S", "K", "S"],
        "wobble_amp": 1.0,
        "time_ratio": 0.3,
        "presence": 0.6,
        "parasite_count": 5,
        "parasite_types": [],
    },
}
#

UTILITAIRES

#

Masque toutes les collections Génération_X déjà existantes dans le viewport

def hide_previous_generations():
#

sans les supprimer, pour garder un affichage lisible lors des générations successives.

    layer_coll = bpy.context.view_layer.layer_collection
    for child in layer_coll.children:
        if child.collection.name.startswith("Génération_"):
            child.hide_viewport = True
            print(f"  Masquée : {child.collection.name}")
#

Crée une nouvelle collection nommée ‘Génération_N’ où N est

def next_generation_collection():
#

automatiquement incrémenté à partir des collections existantes.

    indices = []
    for c in bpy.data.collections:
        if c.name.startswith("Génération_"):
            try:
                indices.append(int(c.name.split("_")[1]))
            except (IndexError, ValueError):
                pass
    n = max(indices) + 1 if indices else 1
    coll = bpy.data.collections.new(f"Génération_{n}")
    bpy.context.scene.collection.children.link(coll)
    return coll
#

Force la collection active dans le layer_collection,

def set_active_collection(coll):
#

ce qui garantit que les primitive_add s’y placent directement.

    layer_coll = bpy.context.view_layer.layer_collection
#
    def find_layer(lc, target):
        if lc.collection == target:
            return lc
        for child in lc.children:
            result = find_layer(child, target)
            if result:
                return result
        return None

    lc = find_layer(layer_coll, coll)
    if lc:
        bpy.context.view_layer.active_layer_collection = lc
#

Fusionne les vertices en double et recalcule les normales

def topology_clean(obj):
#

pour garantir un mesh propre avant les opérations booléennes.

    bm = bmesh.new()
    bm.from_mesh(obj.data)
    bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(obj.data)
    bm.free()
#

Applique un modificateur booléen et le résout immédiatement.

def apply_boolean(obj, name, cutter, operation="INTERSECT", solver="EXACT"):
#

use_self et use_hole_tolerant activés pour éviter les artefacts sur les géométries complexes avec auto-intersections.

    mod = obj.modifiers.new(name, "BOOLEAN")
    mod.operation = operation
    mod.object = cutter
    mod.solver = solver
    if solver == "EXACT":
        mod.use_self = True
        mod.use_hole_tolerant = True
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.modifier_apply(modifier=name)
#

GÉOMÉTRIE — WOBBLE (surface fluide)

#

Génère la surface fluide principale à partir d’une grille subdivisée.

def build_wobble(dna, coll):
#

La hauteur de chaque vertex est calculée par superposition de trois ondes sinusoïdales paramétrées par wobble_amp, time_ratio et presence. La surface est ensuite extrudée vers le bas pour former un volume fermé, nécessaire aux opérations booléennes ultérieures.

Phases aléatoires pour éviter la symétrie entre les générations

    px, py = random.uniform(0, math.pi * 2), random.uniform(0, math.pi * 2)
    base_z = (
        2.0 + dna["time_ratio"] * 6.0
    )  # Hauteur de base selon la position temporelle
    fa = 0.5  # Fréquence principale (constante)
    fb = 0.85 * dna["presence"]  # Fréquence secondaire (modulée par presence)

    set_active_collection(coll)
    bpy.ops.mesh.primitive_grid_add(
        x_subdivisions=48,
        y_subdivisions=48,
        size=CUBE_SIZE + OVERSHOOT * 2,
        location=(0, 0, 0),
    )
    obj = bpy.context.active_object
    obj.name = "Wobble"
#

Déformation Z de chaque vertex par combinaison de trois ondes

    for v in obj.data.vertices:
        wave = (
            math.sin(v.co.x * fa + px)
            + math.cos(v.co.y * fa + py)
            + 0.5 * math.sin((v.co.x - v.co.y) * fb)
        )
        v.co.z = base_z + dna["wobble_amp"] * wave * 0.35
#

Subdivision légère pour lisser la surface avant extrusion

    sub = obj.modifiers.new("Sub", "SUBSURF")
    sub.levels = 1
    bpy.ops.object.modifier_apply(modifier="Sub")
#

Extrusion vers le bas pour fermer le volume (requis pour les booleans)

    bpy.ops.object.mode_set(mode="EDIT")
    bpy.ops.mesh.select_all(action="SELECT")
    bpy.ops.mesh.extrude_region_move(TRANSFORM_OT_translate={"value": (0, 0, -20.0)})
    bpy.ops.object.mode_set(mode="OBJECT")
#

Aplatissement du bas pour éviter les artefacts sous la scène

    bm = bmesh.new()
    bm.from_mesh(obj.data)
    for v in bm.verts:
        if v.co.z < 0:
            v.co.z = -5.0
    bm.to_mesh(obj.data)
    bm.free()

    topology_clean(obj)
    for p in obj.data.polygons:
        p.use_smooth = True  # Shading lisse pour la surface fluide
    return obj
#

GÉOMÉTRIE — BEAT MATRIX (grille rythmique)

#

Génère la grille de barres orthogonales représentant le rythme.

def build_beat_matrix(dna, coll):
#

Trois orientations de barres (X, Y, Z) sont croisées aux 16 intersections de la grille 4x4. Les barres Z (piliers) sont toujours présentes. Les barres X et Y (horizontales) sont supprimées aléatoirement à 25% pour alléger la structure visuellement et réduire la charge booléenne.

    bm = bmesh.new()
    rhythm = dna["rhythm"]

    for i, px in enumerate(POS):
        for j, py in enumerate(POS):
#

Épaisseur selon la position dans le pattern rythmique

            t = THICK_K if rhythm[(i + j) % 4] == "K" else THICK_S
#

Matrices de transformation pour chaque orientation de barre

            mz = Matrix.LocRotScale(
                Vector((px, py, OFFSET)),
                None,
                Vector((t, t, CUBE_SIZE + OVERSHOOT)),
            )
            my = Matrix.LocRotScale(
                Vector((px, 0, py + OFFSET)),
                None,
                Vector((t, CUBE_SIZE + OVERSHOOT, t)),
            )
            mx = Matrix.LocRotScale(
                Vector((0, px, py + OFFSET)),
                None,
                Vector((CUBE_SIZE + OVERSHOOT, t, t)),
            )

            bmesh.ops.create_cube(
                bm,
                size=1.0,
                matrix=mz,
            )  # Pilier vertical : toujours présent
            if random.random() > 0.25:  # Poutre Y : 75% de chance d'apparaître
                bmesh.ops.create_cube(bm, size=1.0, matrix=my)
            if random.random() > 0.25:  # Poutre X : 75% de chance d'apparaître
                bmesh.ops.create_cube(bm, size=1.0, matrix=mx)

    mesh = bpy.data.meshes.new("Beat_Matrix")
    bm.to_mesh(mesh)
    bm.free()
    obj = bpy.data.objects.new("Beat_Matrix", mesh)
    coll.objects.link(obj)
    topology_clean(obj)
    return obj
#

GÉOMÉTRIE — PARASITES (piques organiques)

#

Construit un prisme triangulaire effilé (pique) dans un bmesh existant.

def build_spike(bm, origin, direction, length, radius, p_type="Alpha"):
#

La base est un triangle équilatéral, le sommet est réduit selon le type : - Alpha : très effilé (ratio 0.15) — simule une aiguille - Beta : peu effilé (ratio 0.60) — simule un cône large

La transformation finale aligne l’axe Z local sur la direction donnée, avec une légère inclinaison aléatoire pour casser la rigidité.

    r_top = radius * (0.15 if p_type == "Alpha" else 0.6)
#

Vertices de la base triangulaire (plan XY local)

    vbase = [
        (0, radius, 0),
        (radius * 0.866, -radius * 0.5, 0),
        (-radius * 0.866, -radius * 0.5, 0),
    ]
#

Vertices du sommet (triangle réduit à hauteur length)

    vtop = [
        (0, r_top, length),
        (r_top * 0.866, -r_top * 0.5, length),
        (-r_top * 0.866, -r_top * 0.5, length),
    ]

    vs = [bm.verts.new(co) for co in vbase + vtop]
    v1, v2, v3, v4, v5, v6 = vs
#

Construction des 5 faces du prisme

    bm.faces.new((v1, v3, v2))  # Face de base
    bm.faces.new((v4, v5, v6))  # Face du sommet
    bm.faces.new((v1, v2, v5, v4))  # Face latérale 1
    bm.faces.new((v2, v3, v6, v5))  # Face latérale 2
    bm.faces.new((v3, v1, v4, v6))  # Face latérale 3
#

Légère inclinaison aléatoire pour casser la symétrie radiale

    tilt = Euler((random.uniform(-math.radians(15), math.radians(15)),) * 3, "XYZ")
    rot = Vector((0, 0, 1)).rotation_difference(direction.normalized())
    mat = (
        Matrix.Translation(origin)
#

Génère l’ensemble des parasites selon les paramètres du moment musical.

        @ rot.to_matrix().to_4x4()
        @ tilt.to_matrix().to_4x4()
    )

    for v in vs:
        v.co = mat @ v.co


def build_parasites(dna, coll):
#

Le nombre final fluctue légèrement autour de la valeur de base (±2/3). Chaque parasite est positionné aléatoirement dans la boîte de délimitation, avec des paramètres géométriques distincts selon son type.

Variation stochastique du nombre de parasites

    count = dna["parasite_count"] + (
        random.randint(-2, 3) if dna["parasite_count"] > 10 else 0
    )

    if not dna["parasite_types"] or count <= 0:
        print("  Parasites  : aucun pour ce moment")
        return None, 0

    bm = bmesh.new()
    for _ in range(count):
        p_type = random.choice(dna["parasite_types"])

        if p_type == "Alpha":
#

Alpha : grande aiguille tombante depuis le haut de la boîte

            l = 15.0
            r = random.uniform(0.4, 1.0)
            z0 = CUBE_SIZE - 0.2
        else:
#

Beta : cône court dispersé dans le volume

            l = random.uniform(1.5, 3.0)
            r = random.uniform(0.7, 1.2)
            z0 = random.uniform(2.0, CUBE_SIZE - 2.0)
#

Position XY libérée de la grille pour une dispersion organique

        origin = Vector((random.uniform(-4.5, 4.5), random.uniform(-4.5, 4.5), z0))
        build_spike(bm, origin, Vector((0, 0, -1)), l, r, p_type)

    mesh = bpy.data.meshes.new("Parasites")
    bm.to_mesh(mesh)
    bm.free()
    obj = bpy.data.objects.new("Parasites", mesh)
    coll.objects.link(obj)
    topology_clean(obj)
    return obj, count
#

SYNTHÈSE PRINCIPALE

#

Point d’entrée de la génération. Sélectionne aléatoirement un moment

def synthesize():
#

musical, crée la collection de destination, construit les trois géométries (wobble, beat matrix, parasites) puis applique la chaîne de booleans :

  1. Beat Matrix DIFFERENCE Wobble → les barres sous la surface disparaissent
  2. Beat Matrix INTERSECT Box → les barres sont découpées dans la boîte
  3. Parasites DIFFERENCE Wobble → idem pour les parasites
  4. Parasites INTERSECT Box → idem
  5. Wobble INTERSECT Box → la surface est limitée à la boîte
    moment_name, dna = random.choice(list(MOMENTS.items()))
    coll = next_generation_collection()

    print(f"\n{'=' * 52}")
    print(f"  {coll.name}  —  {moment_name}")
    print(f"{'=' * 52}")
#

— Limit Box — Cube invisible utilisé uniquement comme cutter booléen final. display_type BOUNDS l’affiche en fil de fer sans alourdir la scène.

    set_active_collection(coll)
    bpy.ops.mesh.primitive_cube_add(size=CUBE_SIZE, location=(0, 0, OFFSET))
    box = bpy.context.active_object
    box.name = "Limit_Box"
    box.display_type = "BOUNDS"
    box.hide_render = True
#

— Construction des géométries —

    wobble = build_wobble(dna, coll)
    beat = build_beat_matrix(dna, coll)
    para, count = build_parasites(dna, coll)
#

— Chaîne booléenne —

    apply_boolean(beat, "Slice", wobble, "DIFFERENCE")
    apply_boolean(beat, "Limit", box, "INTERSECT")
    if para:
        apply_boolean(para, "Slice", wobble, "DIFFERENCE")
        apply_boolean(para, "Limit", box, "INTERSECT")
    apply_boolean(wobble, "Limit", box, "INTERSECT")
#

Suppression de la Limit Box après usage (elle ne doit pas rester en scène)

    bpy.data.objects.remove(box, do_unlink=True)
#

— Rapport console —

    print(f"  Amplitude  : {dna['wobble_amp']}")
    print(f"  Z-Ratio    : {dna['time_ratio']}")
    print(f"  Présence   : {dna['presence']}")
    print(f"  Parasites  : {count}")
    print(f"{'=' * 52}\n")
#

EXÉCUTION Appeler synthesize() plusieurs fois pour générer plusieurs configurations dans des collections distinctes (Génération_1, Génération_2, etc.)

if __name__ == "__main__":
    hide_previous_generations()  # Masque les générations existantes
    synthesize()  # Génère la nouvelle dans une collection visible